TIP Sample App/TwitterAPI.m (284 lines of code) (raw):

// // TwitterAPI.m // TwitterImagePipeline // // Created on 2/3/17. // Copyright © 2020 Twitter. All rights reserved. // #import "TwitterAPI.h" @import Accounts; @import Social; @import TwitterImagePipeline; FOUNDATION_EXTERN NSString *TIPURLEncodeString(NSString *string); @interface TweetImageInfo () - (instancetype)initWithBaseURLString:(NSString *)baseURLString format:(NSString *)format originalDimensions:(CGSize)originalDimensions; @end @interface TweetInfo () - (instancetype)initWithHandle:(NSString *)handle text:(NSString *)text images:(NSArray<TweetImageInfo *> *)images; @end @interface TwitterAPI () - (void)loadAccount; @end @implementation TwitterAPI { NSMutableArray *_accountLoadBlocks; ACAccount *_account; ACAccountStore *_accountStore; BOOL _loadingAccount; dispatch_queue_t _apiQueue; } - (instancetype)init { self = [super init]; if (self) { _accountStore = [[ACAccountStore alloc] init]; _apiQueue = dispatch_queue_create("Twitter.API.queue", DISPATCH_QUEUE_SERIAL); _accountLoadBlocks = [[NSMutableArray alloc] init]; } return self; } + (instancetype)sharedInstance { static TwitterAPI *sAPI = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sAPI = [[TwitterAPI alloc] init]; [sAPI loadAccount]; }); return sAPI; } - (void)loadAccount { dispatch_async(_apiQueue, ^{ [self _api_loadAccount:NULL]; }); } - (void)_api_loadAccount:(dispatch_block_t)complete { if (_account) { if (complete) { complete(); } return; } if (complete) { [_accountLoadBlocks addObject:complete]; } if (_loadingAccount) { return; } if (@available(iOS 11, *)) { // for when assertions are disabled NSString *reason = @"\n\n=== Current iOS not supported ===" "\nThis TIP Sample App has not yet been upgraded to run on iOS 11 or later;" "\nit requires iOS Accounts.framework access, which was removed in iOS 11." "\n===\n\n"; @throw [NSException exceptionWithName:@"TIPSampleAppRunningOnUnsupportedOSVersion" reason:reason userInfo:@{@"OSVersion": NSProcessInfo.processInfo.operatingSystemVersionString}]; } #if __IPHONE_11_0 > __IPHONE_OS_VERSION_MIN_REQUIRED NSLog(@"Accessing Twitter Account..."); id<TwitterAPIDelegate> delegate = self.delegate; if ([delegate respondsToSelector:@selector(APIWorkStarted:)]) { [delegate APIWorkStarted:self]; } _loadingAccount = YES; ACAccountType *type = [_accountStore accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter]; [_accountStore requestAccessToAccountsWithType:type options:nil completion:^(BOOL granted, NSError *error) { dispatch_async(self->_apiQueue, ^{ if (granted) { self->_account = self->_accountStore.accounts.firstObject; NSLog(@"Access granted: %@", self->_account.username); } else { NSLog(@"Access denied!"); } if ([delegate respondsToSelector:@selector(APIWorkFinished:)]) { [delegate APIWorkFinished:self]; } self->_loadingAccount = NO; NSArray *completionBlocks = [self->_accountLoadBlocks copy]; [self->_accountLoadBlocks removeAllObjects]; for (dispatch_block_t block in completionBlocks) { block(); } }); }]; #endif } - (void)searchForTerm:(NSString *)term count:(NSUInteger)count complete:(void (^)(NSArray<TweetInfo *> *, NSError *))complete { dispatch_async(_apiQueue, ^{ [self _api_loadAccount:^{ [self _api_searchForTerm:term count:count complete:complete]; }]; }); } - (void)_api_searchForTerm:(NSString *)term count:(NSUInteger)count complete:(void (^)(NSArray<TweetInfo *> *, NSError *))complete { NSLog(@"Searching for '%@'", term); NSError *error = nil; NSURLRequest *preparedRequest = nil; if (!_account) { error = [NSError errorWithDomain:@"Twitter.API" code:0 userInfo:@{ @"message" : @"couldn't open Twitter account!"}]; } else if (!term) { error = [NSError errorWithDomain:@"Twitter.API" code:1 userInfo:@{ @"message" : @"nil search term!" }]; } else { NSURL *requestURL = [NSURL URLWithString:@"https://api.twitter.com/1.1/search/tweets.json"]; NSDictionary *params = @{ @"count" : @(count).stringValue, @"adc" : @"phone", @"q" : term, }; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" SLRequest *request = [SLRequest requestForServiceType:SLServiceTypeTwitter requestMethod:SLRequestMethodGET URL:requestURL parameters:params]; #pragma clang diagnostic pop request.account = _account; preparedRequest = request.preparedURLRequest; } if (!error && !preparedRequest) { error = [NSError errorWithDomain:@"Twitter.API" code:2 userInfo:@{ @"message" : @"couldn't construct request!" }]; } if (preparedRequest) { id<TwitterAPIDelegate> delegate = self.delegate; NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:preparedRequest completionHandler:^(NSData *data, NSURLResponse *response, NSError *theError) { dispatch_async(self->_apiQueue, ^{ NSError *blockError = theError; NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode]; if (!blockError && statusCode != 200) { blockError = [NSError errorWithDomain:@"Twitter.API" code:3 userInfo:@{ @"statusCode" : @(statusCode), @"message" : [NSString stringWithFormat:@"HTTP %zi", statusCode] }]; } NSArray<TweetInfo *> *parsedResponse = (error) ? nil : [self _api_parseResponse:data]; if (!parsedResponse) { blockError = [NSError errorWithDomain:@"Twitter.API" code:4 userInfo:@{ @"message" : @"failed to parse response!" }]; } if ([delegate respondsToSelector:@selector(APIWorkFinished:)]) { [delegate APIWorkFinished:self]; } if (complete) { dispatch_async(dispatch_get_main_queue(), ^{ if (error) { NSLog(@"Search failed: %@", blockError); } else { NSLog(@"Search completed!"); } complete(parsedResponse, blockError); }); } }); }]; if ([delegate respondsToSelector:@selector(APIWorkStarted:)]) { [delegate APIWorkStarted:self]; } [task resume]; } if (error && complete) { dispatch_async(dispatch_get_main_queue(), ^{ NSLog(@"Search failed: %@", error); complete(nil, error); }); } } - (NSArray<TweetInfo *> *)_api_parseResponse:(NSData *)data { NSMutableArray<TweetInfo *> *tweets = nil; @try { NSDictionary *JSONObject = [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; NSArray *statuses = JSONObject[@"statuses"]; tweets = [[NSMutableArray alloc] initWithCapacity:statuses.count]; for (NSDictionary *status in statuses) { NSDictionary *user = status[@"user"]; NSString *handle = user[@"screen_name"]; if (handle) { NSString *text = status[@"text"]; NSMutableArray<TweetImageInfo *> *images = nil; NSDictionary *entities = status[@"entities"]; const BOOL sensitive = [status[@"possibly_sensitive"] boolValue]; if (!sensitive) { NSArray *media = entities[@"media"]; images = [[NSMutableArray alloc] initWithCapacity:4]; for (NSDictionary *mediaItem in media) { NSString *type = mediaItem[@"type"]; if ([type isEqual:@"photo"]) { NSString *imageURLString = mediaItem[@"media_url_https"]; if (imageURLString) { NSString *format = imageURLString.pathExtension; NSString *baseURLString = [imageURLString substringToIndex:imageURLString.length - (format.length + 1)]; NSDictionary *sizes = mediaItem[@"sizes"]; NSDictionary *largeVariant = sizes[@"large"]; NSInteger w = [largeVariant[@"w"] integerValue]; NSInteger h = [largeVariant[@"h"] integerValue]; if (0 != w && 0 != h) { TweetImageInfo *image = [[TweetImageInfo alloc] initWithBaseURLString:baseURLString format:format originalDimensions:CGSizeMake(w, h)]; [images addObject:image]; } } } } } TweetInfo *tweet = [[TweetInfo alloc] initWithHandle:handle text:text images:(images.count > 0) ? images : nil]; [tweets addObject:tweet]; } } } @catch (NSException *exception) { // in case we access something unexpected NSLog(@"Exception! %@", exception); } return (tweets.count > 0) ? tweets : nil; } @end @implementation TweetImageInfo - (instancetype)initWithBaseURLString:(NSString *)baseURLString format:(NSString *)format originalDimensions:(CGSize)originalDimensions { if (self = [super init]) { _baseURLString = [baseURLString copy]; _format = [format copy]; _originalDimensions = originalDimensions; } return self; } - (NSString *)description { return [@{ @"URL" : _baseURLString, @"format" : _format, @"dimensions" : [NSValue valueWithCGSize:_originalDimensions] } description]; } @end @implementation TweetInfo - (instancetype)initWithHandle:(NSString *)handle text:(NSString *)text images:(NSArray<TweetImageInfo *> *)images { if (self = [super init]) { _handle = [@"@" stringByAppendingString:handle]; _text = [text copy]; _images = [images copy]; } return self; } -(NSString *)description { return [@{ @"handle" : _handle, @"text" : _text, @"images" : (_images ?: [NSNull null]) } description]; } @end #define kSMALL @"small" #define kMEDIUM @"medium" #define kLARGE @"large" typedef struct { void * const name; CGFloat const dim; } VariantInfo; static VariantInfo const sVariantSizeMap[] = { { .name = kSMALL, .dim = 680 }, { .name = kMEDIUM, .dim = 1200 }, { .name = kLARGE, .dim = 2048 }, }; NSString *TweetImageDetermineVariant(CGSize aspectRatio, const CGSize dimensions, UIViewContentMode contentMode) { if (aspectRatio.height <= 0 || aspectRatio.width <= 0) { aspectRatio = CGSizeMake(1, 1); } const BOOL scaleToFit = (UIViewContentModeScaleAspectFit == contentMode); const CGSize scaledToTargetDimensions = TIPDimensionsScaledToTargetSizing(aspectRatio, dimensions, (scaleToFit ? UIViewContentModeScaleAspectFit : UIViewContentModeScaleAspectFill)); NSString * selectedVariantName = nil; for (size_t i = 0; i < (sizeof(sVariantSizeMap) / sizeof(sVariantSizeMap[0])); i++) { const CGSize variantSize = CGSizeMake(sVariantSizeMap[i].dim, sVariantSizeMap[i].dim); const CGSize scaledToVariantDimensions = TIPDimensionsScaledToTargetSizing(aspectRatio, variantSize, UIViewContentModeScaleAspectFit); if (scaledToVariantDimensions.width >= scaledToTargetDimensions.width && scaledToVariantDimensions.height >= scaledToTargetDimensions.height) { selectedVariantName = (__bridge NSString *)sVariantSizeMap[i].name; break; } } if (!selectedVariantName) { selectedVariantName = kLARGE; } return selectedVariantName; }